Skip to content

feat(storage): Implement ObjectStoreStorage::S3 (Supersedes #2257) - #3165

Open
Sruhvx-jpg wants to merge 12 commits into
apache:mainfrom
Sruhvx-jpg:revive-os-s3
Open

Sruhvx-jpg wants to merge 12 commits into
apache:mainfrom
Sruhvx-jpg:revive-os-s3

Conversation

@Sruhvx-jpg

@Sruhvx-jpg Sruhvx-jpg commented Sep 7, 2026

Copy link
Copy Markdown

Which issue does this PR close?

What changes are included in this PR?

Implement ObjectStoreStorage::S3 backed by Apache Arrow's object_store crate. Originally drafted by @CTTY in #2257 and revived onto current main:

  • Hoist object_store 0.13 to workspace dependencies (aligned with DataFusion).
  • Support s3://, s3a://, and s3n:// URL schemes with empty bucket validation.
  • Implement zero-copy writes via WriteMultipart::put(bs) instead of slice copying.
  • Implement concurrent delete_stream using try_for_each_concurrent.
  • Add unit tests for URL parsing (including edge cases) and FileIO/Storage serialization roundtrips.
  • Wire crate workspace lints and publish flag.

Are these changes tested?

Yes, all 12 unit tests covering S3 URL parsing, empty bucket checks, store cache reuse, and FileIO/StorageFactory serialization roundtrips passing (cargo test -p iceberg-storage-object_store).

CTTY and others added 2 commits September 7, 2026 14:59
…oncurrent deletes

- Hoist `object_store` 0.13 to workspace dependencies to align with DataFusion.
- Support `s3n://` scheme alongside `s3://` and `s3a://` in `parse_s3_url`.
- Optimize `delete_stream` with `try_for_each_concurrent` instead of sequential loop.
- Add unit tests for `s3n://` URL parsing and FileIO/Storage serialization roundtrips.
- Wire crate workspace lints and publish flag.
@Sruhvx-jpg

Copy link
Copy Markdown
Author

Apologies for any notification noise from the extra PR earlier. Everything has been cleanly unified into this PR :)

@Sruhvx-jpg
Sruhvx-jpg force-pushed the revive-os-s3 branch 2 times, most recently from a476be2 to 37180ce Compare September 11, 2026 17:56
@Sruhvx-jpg

Copy link
Copy Markdown
Author

Hey everyone, got all the CI checks passing and green now!

Since this is a pretty big PR, just wanted to say that if you guys like the work, I'd really love to stick around and continue making it better—handling any feedback, tuning performance, and helping add other backends like GCS or Azure down the line.

Whenever you get some time to check it out, let me know what you think! :)

@Sruhvx-jpg

Copy link
Copy Markdown
Author

cc @CTTY @kevinjqliu — CI is completely green on this.

Since this directly revives and finishes #2257, whenever you have a moment to take a look, I'd really appreciate your review on the S3 backend implementation!

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really glad to see object_store wired into the Storage framework — the factory, per-bucket cache, and path plumbing are all in good shape, and this is the right base for the follow-up backends. I'd hold it before merge though, since the whole object_store stack is going to build on top of this crate and a few of these are hard to walk back once it's published.

The one that worries me most is build_s3_store silently dropping most of S3Config. When an operator sets s3.sse.type=kms or custom, TryFrom populates the SSE fields but nothing forwards them to the builder, so we'd write data unencrypted even though encryption was explicitly required — a silent security regression versus opendal/Java. AmazonS3Builder has with_sse_kms_encryption/with_ssec_encryption for this, and for the fields object_store genuinely can't express (assume-role, disable-ec2-metadata) I'd return an error rather than drop them silently.

Things I'd like to settle in this PR before the follow-ups build on it:

  • Forward the SSE config, and error on the config fields object_store can't express instead of dropping them
  • Rework parse_s3_url to use the parsed Url fields instead of slicing the raw string (uppercase scheme + percent-encoded bucket both break today)
  • Make the store_cache/config variant fields private before the first publish
  • Add a Drop that aborts the multipart upload so a dropped writer doesn't orphan parts
  • Land at least a thin integration test against localstack/MinIO — nothing currently exercises a real read/write

None of it is structural — the design is right. Once those are addressed I'm happy to take another pass and approve.

Comment thread crates/storage/object_store/src/s3.rs Outdated
Error::new(ErrorKind::DataInvalid, format!("Invalid URL: {path}")).with_source(e)
})?;

let scheme = &path[..url.scheme().len()];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parses with Url but then slices the raw input using lengths taken from the normalized parsed fields, and the two don't always line up.

Two concrete failures: an uppercase scheme like S3://bucket/key gets sliced as &path[..2] = "S3", which falls through the match to the unsupported-scheme error and rejects a valid URL. And url.host_str() is percent-decoded, so s3://my%2Dbucket/key gives bucket_str = "my-bucket" (9 bytes) while the raw span is 11 bytes — the bucket slice at line 67 returns the wrong bytes and prefix_len is off, which can panic on a char boundary.

I'd match on url.scheme() directly (it's already lowercased) and pull bucket/relative from url.host_str() / url.path().trim_start_matches('/'), returning owned Strings instead of slicing the input — that's what the opendal sibling does. wdyt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i find using existing code be used again to be fit

}

/// Build an `AmazonS3` store from iceberg's `S3Config` for a given bucket.
pub(crate) fn build_s3_store(config: &S3Config, bucket: &str) -> Result<Arc<dyn ObjectStore>> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This maps 7 of the 16 S3Config fields and silently drops the rest, and the SSE fields are the dangerous ones: when an operator sets s3.sse.type=kms or custom, TryFrom populates the SSE fields on S3Config but nothing here forwards them, so we write data unencrypted even though encryption was explicitly required. AmazonS3Builder exposes with_sse_kms_encryption / with_ssec_encryption, and the opendal sibling maps all three SSE types — I'd mirror that.

The assume-role fields (role_arn, external_id, role_session_name) and disable_ec2_metadata / disable_config_load are also dropped, and object_store has no builder API for those. Silently ignoring them is worse than not supporting them — a role-based config falls through to the credential chain and only fails at first I/O. I'd return a FeatureUnsupported/DataInvalid error listing the unsupported non-default fields rather than dropping them.

Fix the SSE forwarding and error on the fields we can't express, and this one's resolved.

Comment thread crates/storage/object_store/src/lib.rs Outdated
config: Arc<S3Config>,
/// Per-bucket store cache.
#[serde(skip, default)]
store_cache: Arc<DashMap<String, Arc<dyn ObjectStore>>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this crate is publish = true, these two variant fields become part of the public API the moment it hits crates.io — public-api.txt already records both as pub. store_cache especially is pure implementation detail; exposing Arc<DashMap<...>> as a public field locks the cache structure into semver, so we couldn't later switch to Mutex<HashMap> or add a store abstraction without a breaking change.

I'd wrap the variant data in a struct with private fields and a pub fn new(config) constructor, exposing the config through a getter if callers need it. Better to lock this down before the first publish than after.


/// Writer that implements `FileWrite` using `object_store` multipart upload.
struct ObjectStoreWriter {
writer: Option<WriteMultipart>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WriteMultipart doesn't complete or abort on drop, and there's no Drop impl here, so if an ObjectStoreWriter is dropped without close() — panic unwind, an early ? return, a cancelled future — the uploaded parts are orphaned in the bucket, billed indefinitely and never committed.

I'd add a Drop that best-effort aborts the inner WriteMultipart via take(). Worth flagging that no test will catch this since it only surfaces as leaked S3 state. wdyt?

Comment thread crates/storage/object_store/src/lib.rs Outdated

/// Convert an `object_store::Error` into an `iceberg::Error`.
fn from_object_store_error(e: object_store::Error) -> Error {
Error::new(ErrorKind::Unexpected, "Failure in doing io operation").with_source(e)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This collapses every object_store::Error to ErrorKind::Unexpected, so a NotFound coming back from read/metadata/delete is indistinguishable from a network failure without downcasting the source. exists special-cases NotFound itself, but the others don't, and callers rely on ErrorKind::NotFound for control flow like commit-conflict detection and manifest reads.

I'd dispatch on the object_store::Error variant here — NotFoundErrorKind::NotFound, PermissionDenied → the closest matching kind, else Unexpected — so every caller gets the right kind for free.

use super::*;

#[cfg(feature = "object_store-s3")]
fn make_s3_storage() -> ObjectStoreStorage {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests here all run against S3Config::default(), and AmazonS3Builder::build() doesn't validate eagerly, so the cache and roundtrip tests pass without ever touching a backend — none of write/read/reader/delete/delete_prefix/delete_stream/metadata is actually exercised. That's false confidence about exactly the paths most likely to break (multipart lifecycle, serial-vs-batch delete, range reads).

The opendal sibling has a localstack-backed test in CI. I'd add a feature/env-gated integration target covering a write+read roundtrip, a range read, and delete_prefix over 10+ objects before the follow-up backends lean on this crate. wdyt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

am not the best at this, so here i willl take ur and ai assistance

Comment thread crates/storage/object_store/Cargo.toml Outdated
futures = { workspace = true }
iceberg = { workspace = true }
object_store = { workspace = true }
serde = { workspace = true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serde = { workspace = true } inherits only features = ["rc"], but this crate uses #[derive(Serialize, Deserialize)]. It compiles in-workspace only because typetag/iceberg happen to activate serde/derive through feature unification — a downstream consumer depending on just this crate would hit use of undeclared crate serde_derive.

Since publish = true, I'd declare it explicitly: serde = { workspace = true, features = ["derive"] }.

Comment thread crates/storage/object_store/src/lib.rs Outdated

async fn delete_prefix(&self, path: &str) -> Result<()> {
let (store, object_path) = self.get_store_and_path(path)?;
let prefix = if object_path.as_ref().ends_with('/') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectStorePath::from always strips trailing slashes, so ends_with('/') is always false and the else branch just re-appends-then-strips — this whole if/else collapses to let prefix = object_path;. It's only correct today because store.list matches on path-segment boundaries anyway.

Comment thread crates/storage/object_store/src/lib.rs Outdated
async fn delete_stream(&self, paths: BoxStream<'static, String>) -> Result<()> {
paths
.map(Ok)
.try_for_each_concurrent(16, |path| async move {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same batching point as delete_prefix — this issues one DeleteObject per path capped at 16 in flight, where DeleteObjects takes 1,000 per request. I'd route this through store.delete_stream(paths) too; if we keep the concurrent form, pull the 16 out into a named const.

Comment thread crates/storage/object_store/src/lib.rs Outdated
.await
.map_err(from_object_store_error)?;
Ok(FileMetadata {
size: meta.size as u64,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ObjectMeta::size is already u64 in object_store 0.13, so this cast is a no-op that trips clippy::useless_conversion. Just size: meta.size,.

@Sruhvx-jpg

Sruhvx-jpg commented Sep 14, 2026

Copy link
Copy Markdown
Author

@laskoviymishka thanks for follow up, perhaps it's my fault I didnt properly review the CITY's code.

Now that u have pointed out these issues I suspect there must be more of em, So I would like to take about 2 days minimum to get everything resolved-review-amended and also look for unknown hiccups. This means more research

Currently its night here so I will get to reading ur followup thoroughly tommarrow 😊

Again, thanks for the detailed follow up

@laskoviymishka

Copy link
Copy Markdown
Contributor

@Sruhvx-jpg no rush, keep your time here.

@Sruhvx-jpg

Copy link
Copy Markdown
Author

@laskoviymishka addressed all the feedback and added several hardening improvements:

  1. SSE & Unexpressible Configs:

    • Wired with_sse_kms_encryption (KMS) and with_ssec_encryption (SSE-C / custom keys) into build_s3_store.
    • Added explicit fail-fast errors (ErrorKind::FeatureUnsupported) for options object_store can't express (role_arn, disable_ec2_metadata, disable_config_load) instead of dropping them silently.
  2. URL Parsing & Type Encapsulation:

    • Reworked parse_s3_url to use native Url fields (url.scheme(), url.host_str(), url.path()) and encapsulated returns in a ParsedS3Url struct.
    • Dropped all raw string slicing math; uppercase schemes (S3://, S3A://) and percent-encoded paths/buckets are parsed cleanly.
  3. Public API & Cache Encapsulation:

    • Encapsulated store_cache and config inside a private S3Storage struct with a StoreCache type alias. Public enum variant is now opaque ObjectStoreStorage::S3(S3Storage) and public-api.txt is updated.
    • Introduced a StoreAndPath struct wrapper for internal storage dispatch, eliminating tuple destructuring across all 9 Storage methods.
  4. Orphaned Multipart Abort on Drop:

    • Implemented Drop on ObjectStoreWriter to asynchronously trigger writer.abort() via the Tokio runtime if dropped before close().
  5. Type Safety & Redundant Cast Cleanup:

  6. Integration & Unit Tests (AI-assisted):

    • Added crates/storage/object_store/tests/file_io_s3_test.rs testing real read/write/delete against MinIO (matching OpenDAL's test suite).
    • Added comprehensive unit tests for SSE, unexpressible error guards, and URL edge cases. (Used AI specifically to accelerate scaffolding and expanding the test coverage matrix).

All checks, clippy lints, and CI tests are green. Ready for another pass whenever you have time!

@Sruhvx-jpg

Sruhvx-jpg commented Sep 16, 2026

Copy link
Copy Markdown
Author

Also on the struct wrappers (ParsedS3Url and StoreAndPath): coming from a TypeScript & tRPC background, I prefer explicit named structs over anonymous tuples. It keeps the internal contract non-breaking if we expand fields later, eliminates tuple-index swapping bugs, and makes the code much cleaner to read.

If u like such style, we can maybe expand its usage as I have never seen a rust code with wrapper structs, which is understandable as is an repetitive job - but with advent of ai its much easier

@Sruhvx-jpg

This comment was marked as off-topic.

@Sruhvx-jpg

Sruhvx-jpg commented Sep 16, 2026

Copy link
Copy Markdown
Author

@laskoviymishka Hey! All CI's Green - "Make no mistake" worked this time 😄

mixermt added a commit to mixermt/iceberg-rust that referenced this pull request Sep 17, 2026
Inherited from main (tracked in apache#3222); same bump as apache#3165.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost there, thanks for turning this around fast.

The one thing that blocks merge is the SSE-KMS default-key path. Forwarding the key landed, but when s3.sse.type=kms is set with no explicit key we pass "" into with_sse_kms_encryption, which emits an empty key-id header on every PUT and S3 rejects it with InvalidArgument. The store still builds, so writes fail silently — and "use the bucket's default CMK" is the most common KMS setup. Java and opendal send aws:kms with no key-id header there; I'd match that.

Everything else I asked for last round is in: SSE config is forwarded, parse_s3_url is rebuilt on Url fields (uppercase schemes work now), store_cache/config are private, the Drop abort is present, and the MinIO tests exercise real reads and writes.

Two of those aren't fully closed yet, though — both smaller than the blocker:

  • the percent-encoded bucket half of the parse fix is still open (s3://my%2Dbucket/... gives a bucket no S3 store has; the current test pins that behaviour rather than fixing it)
  • the Drop is in, but close() consumes the writer before finish(), so on a failed finish there's nothing left to abort — the exact case it's for — and the multipart writer() path still has no test

Fix the KMS path and I'm happy to take another pass. I'd like the other two closed here too since the follow-up backends build on this, but they're mechanical from where you are now.

Comment thread crates/storage/object_store/src/s3.rs Outdated
}
}

let bucket = url.host_str().ok_or_else(|| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Url rework fixed the uppercase-scheme case from last round, nice. The percent-encoded bucket half is still open though: host_str() returns the host still percent-encoded, so s3://my%2Dbucket/... yields bucket my%2Dbucket, which we hand straight to with_bucket_name and also use as the cache key — no real bucket has that name, and s3://my-bucket vs s3://my%2Dbucket split into two cache entries for the same bucket.

I'd decode the host, or reject any host containing % with DataInvalid. The current test asserts the encoded form, so it's pinning the bug rather than the fix.

.writer
.take()
.ok_or_else(|| Error::new(ErrorKind::Unexpected, "Writer has already been closed"))?;
writer.finish().await.map_err(from_object_store_error)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Drop-abort from last round is in, but it can't fire in the case it's for.

close() takes the writer out before finish().await, so if finish() errors the WriteMultipart is already consumed by value — by the time Drop runs, self.writer is None and the guard skips the abort. That's exactly the transient-error path where parts have already been flushed to S3 and now leak until a lifecycle rule expires them.

Holding the lower-level Box<dyn MultipartUpload> instead of WriteMultipart lets us abort() on a failed complete() and again in Drop. wdyt?

@Sruhvx-jpg Sruhvx-jpg Sep 18, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but holding box<dyn multipartupload> also means we have to write our own WriteMultipart???

Comment thread crates/storage/object_store/src/lib.rs Outdated
};

let mut list_stream = target.store.list(Some(&prefix));
while let Some(entry) = list_stream.next().await {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lists the prefix then deletes one object at a time, each awaited before the next — 10k objects is 10k sequential round-trips, and snapshot expiry hits this with large numbers of manifests. ObjectStoreExt::delete_stream (already imported) maps to S3 DeleteObjects at up to 1000 keys per request.

Piping the list stream into it also lets us drop the trailing-slash branch just above, which is a no-op anyway since ObjectStorePath normalizes trailing slashes. Something like store.list(Some(&prefix)).map_ok(|m| m.location)... fed into delete_stream(...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this one suggestion, got to learn something new

}

#[tokio::test]
async fn test_file_io_s3_output() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MinIO tests landed — that closes the "nothing exercises a real read/write" ask. They all go through write(bytes)put() though; the writer()WriteMultipart path has no coverage, and that's the primary path for streaming Parquet/Avro data files (and where the Drop/abort issues above live).

A single test that writes past the multipart threshold, closes, and reads back — plus one that drops a writer without closing — would cover both the multipart lifecycle and ask #4 end-to-end.

Comment thread crates/storage/object_store/src/s3.rs Outdated
}
"AES256" => {
builder = builder.with_config(
AmazonS3ConfigKey::from_str("aws_server_side_encryption").map_err(|e| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch reaches into object_store internals by string, which gives us an Unexpected error path that can never fire and a silent dependency on a private constant across a crate boundary. There's a typed form:

builder = builder.with_config(
    AmazonS3ConfigKey::Encryption(S3EncryptionConfigKey::ServerSideEncryption),
    "AES256",
);

Worth a server_side_encryption = "AES256" unit test too, since nothing exercises this branch today.

Comment thread crates/storage/object_store/src/s3.rs Outdated
}

if let Some(ref custom_key) = config.server_side_encryption_customer_key {
builder = builder.with_ssec_encryption(custom_key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

server_side_encryption_customer_key_md5 gets populated by TryFrom when s3.sse.md5 is set, but we only forward custom_key here — the MD5 is dropped. Some S3-compatible stores validate the supplied MD5 and reject SSE-C ops without it. Can we check whether with_ssec_encryption computes it for us, and forward it (or log) if not?

Comment thread crates/storage/object_store/src/lib.rs Outdated
impl Drop for ObjectStoreWriter {
fn drop(&mut self) {
if let Some(writer) = self.writer.take()
&& let Ok(handle) = tokio::runtime::Handle::try_current()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more thing on this Drop: if it runs outside a Tokio context (try_current() fails on a sync drop or during shutdown) the abort is silently skipped. I'd at minimum tracing::warn! in that branch so an orphaned upload isn't invisible, or document that abort-on-drop only holds from an async context.

@Sruhvx-jpg

Copy link
Copy Markdown
Author

@laskoviymishka Addressed all feedback from the latest review:

  1. SSE-KMS Default Key: When s3.sse.type=kms is set without an explicit key ID, we now set aws_server_side_encryption to aws:kms via with_config without passing an empty key ID header.
  2. URL Percent-Decoding: parse_s3_url now decodes percent-encoded bucket names via percent_decode_str (my%2Dbucket -> my-bucket), fixing cache key and bucket resolution.
  3. Batch delete_prefix: Piped store.list() into store.delete_stream(), leveraging S3's bulk DeleteObjects (up to 1,000 keys per request).
  4. SSE-C MD5: Verified that with_ssec_encryption automatically computes the MD5 hash via Md5::new() and sets x-amz-server-side-encryption-customer-key-MD5. Added a doc-comment to document this.
  5. Drop Diagnostics: Added tracing::warn! when ObjectStoreWriter is dropped outside an active Tokio runtime context.
  6. Expanded Integration & Unit Tests (AI-assisted):
    • test_file_io_s3_multipart_writer_past_threshold: Writes 12 MiB (crossing the 10 MiB threshold) to test multi-part S3 chunking and assembly end-to-end.
    • test_file_io_s3_delete_prefix_bulk: Tests bulk deletion over 15 objects under a prefix.
    • test_file_io_s3_range_reader: Tests byte-range slicing against S3.
    • test_file_io_s3_multipart_writer_drop_aborts: Tests multipart upload abort on drop.
    • Test cases were scaffolded with AI assistance and verified end-to-end against local S3/LocalStack.

All checks, clippy lints, and integration tests are green. Ready for another pass!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(storage): write pipeline robustness and edge-case handling for object_store backend Implement ObjectStoreStorage::S3

3 participants